Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit b9315d4b3ffa04346993f8a9459f4b3c2ec8f108


Parents : fda8f97
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-05-27T17:33:35-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-05-28T00:33:35Z

feat: adopt gradle-flatpak-sources plugin for offline Flatpak builds (#5619)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Changes
Diff

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 48a27afb90..d1fa993b3f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -371,7 +371,7 @@ jobs:
cache_read_only: 'true'
# Uses an isolated Gradle user home so every artifact actually traverses the
- # network β€” meshtastic.flatpak-ops captures URLs via BuildOperationListener and
+ # network β€” the flatpak-sources plugin captures URLs via BuildOperationListener and
# only sees ExternalResourceReadBuildOperation events for cache misses. With the
# shared cache, downloads would be skipped and the manifest would be (nearly) empty.
# We drive packageUberJarForCurrentOS β€” the same task the in-flatpak build invokes β€”
@@ -381,11 +381,10 @@ jobs:
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
- -I gradle/init-scripts/flatpak-ops.init.gradle.kts
:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
- run: cp build/flatpak-ops-sources.json flatpak-sources.json
+ run: cp build/flatpak-sources.json flatpak-sources.json
- name: List Flatpak source files
run: ls -l flatpak-sources.json

diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 94ea28f9e1..906cc4da12 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -582,11 +582,10 @@ jobs:
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
- -I gradle/init-scripts/flatpak-ops.init.gradle.kts
:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
- run: cp build/flatpak-ops-sources.json flatpak-sources.json
+ run: cp build/flatpak-sources.json flatpak-sources.json
- run: ls -lah flatpak-sources.json

diff --git a/.github/workflows/verify-flatpak.yml b/.github/workflows/verify-flatpak.yml
new file mode 100644
index 0000000000..9cf061c1a4
--- /dev/null
+++ b/.github/workflows/verify-flatpak.yml
@@ -0,0 +1,111 @@
+name: Verify Flatpak Offline Build
+
+on:
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'scripts/verify-flatpak/**'
+ - 'build.gradle.kts'
+ - 'settings.gradle.kts'
+ - '.github/workflows/verify-flatpak.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: flatpak-verify-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ generate-sources:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Generate flatpak-sources.json
+ run: |
+ ./gradlew --no-build-cache --no-configuration-cache \
+ -Dgradle.user.home="$RUNNER_TEMP/flatpak-gradle-home" \
+ :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
+ cp build/flatpak-sources.json flatpak-sources.json
+ echo "### Flatpak Sources Summary" >> "$GITHUB_STEP_SUMMARY"
+ echo "- URLs captured: $(jq length flatpak-sources.json)" >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: flatpak-sources
+ path: flatpak-sources.json
+
+ build-flatpak:
+ needs: generate-sources
+ runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
+ timeout-minutes: 45
+ strategy:
+ matrix:
+ arch: [x86_64, aarch64]
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: flatpak-sources
+
+ - name: Clone vid's flatpak repo
+ run: |
+ git clone --depth 1 --recurse-submodules \
+ https://github.com/vidplace7/org.meshtastic.desktop.git \
+ "$RUNNER_TEMP/org.meshtastic.desktop"
+
+ - name: Wire overlay manifest + sources
+ run: |
+ cp scripts/verify-flatpak/desktop-offline.yaml \
+ "$RUNNER_TEMP/org.meshtastic.desktop/org.meshtastic.desktop.yaml"
+ cp flatpak-sources.json \
+ "$RUNNER_TEMP/org.meshtastic.desktop/flatpak-sources.json"
+ rsync -a --delete \
+ --exclude='/build/' --exclude='/.gradle/' \
+ --exclude='*/build/' --exclude='*/.gradle/' \
+ --exclude='/.idea/' --exclude='/local.properties' \
+ ./ "$RUNNER_TEMP/org.meshtastic.desktop/meshtastic-android/"
+
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq flatpak flatpak-builder
+ flatpak remote-add --user --if-not-exists flathub \
+ https://dl.flathub.org/repo/flathub.flatpakrepo
+
+ - name: Build flatpak offline
+ working-directory: ${{ runner.temp }}/org.meshtastic.desktop
+ run: |
+ flatpak-builder --user --repo=repo --install-deps-from=flathub \
+ --force-clean builddir org.meshtastic.desktop.yaml
+
+ - name: Export .flatpak bundle
+ working-directory: ${{ runner.temp }}/org.meshtastic.desktop
+ env:
+ ARCH: ${{ matrix.arch }}
+ run: |
+ flatpak build-bundle repo org.meshtastic.desktop.${ARCH}.flatpak \
+ org.meshtastic.desktop \
+ --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
+ echo "### βœ… Offline Flatpak build succeeded ($ARCH)" >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: meshtastic-desktop-flatpak-${{ matrix.arch }}
+ path: ${{ runner.temp }}/org.meshtastic.desktop/org.meshtastic.desktop.${{ matrix.arch }}.flatpak

diff --git a/.gitignore b/.gitignore
index b491269206..18fe71d64b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,3 +85,4 @@ flatpak-sources-*.json
flatpak-sources.json
offline-repository/
.claude/
+build-scan-*.scan

diff --git a/build-logic/flatpak-ops/build.gradle.kts b/build-logic/flatpak-ops/build.gradle.kts
deleted file mode 100644
index 04ed7143a9..0000000000
--- a/build-logic/flatpak-ops/build.gradle.kts
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-
-plugins {
- `kotlin-dsl`
- alias(libs.plugins.spotless)
- alias(libs.plugins.detekt)
-}
-
-group = "org.meshtastic.flatpakops"
-
-java {
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
-}
-
-kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } }
-
-dependencies {
- implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
- detektPlugins(libs.detekt.formatting)
-}
-
-tasks {
- validatePlugins {
- enableStricterValidation = true
- failOnWarning = true
- }
-}
-
-spotless {
- ratchetFrom("origin/main")
- kotlin {
- target("src/*/kotlin/**/*.kt", "src/*/java/**/*.kt")
- targetExclude("**/build/**/*.kt")
- ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
- ktlint(libs.versions.ktlint.get())
- .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
- licenseHeaderFile(rootProject.file("../config/spotless/copyright.kt"))
- }
- kotlinGradle {
- target("**/*.gradle.kts")
- ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
- ktlint(libs.versions.ktlint.get())
- .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
- licenseHeaderFile(rootProject.file("../config/spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
- }
-}
-
-detekt {
- toolVersion = libs.versions.detekt.get()
- config.setFrom(rootProject.file("../config/detekt/detekt.yml"))
- buildUponDefaultConfig = true
- allRules = false
- source.setFrom(files("src/main/java", "src/main/kotlin"))
-}
-
-gradlePlugin {
- plugins {
- register("meshtasticFlatpakOps") {
- id = "meshtastic.flatpak-ops"
- implementationClass = "org.meshtastic.flatpakops.FlatpakOpsPlugin"
- }
- }
-}

diff --git a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt b/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
deleted file mode 100644
index 9ed009c38a..0000000000
--- a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.flatpakops
-
-import groovy.json.JsonOutput
-import groovy.json.JsonSlurper
-import org.gradle.api.Plugin
-import org.gradle.api.Project
-import org.gradle.api.internal.project.ProjectInternal
-import org.gradle.internal.operations.BuildOperationDescriptor
-import org.gradle.internal.operations.BuildOperationListener
-import org.gradle.internal.operations.BuildOperationListenerManager
-import org.gradle.internal.operations.OperationFinishEvent
-import org.gradle.internal.operations.OperationIdentifier
-import org.gradle.internal.operations.OperationProgressEvent
-import org.gradle.internal.operations.OperationStartEvent
-import org.gradle.internal.resource.ExternalResourceReadBuildOperationType
-import java.io.File
-import java.net.URI
-import java.security.MessageDigest
-import java.util.concurrent.ConcurrentHashMap
-
-/**
- * Captures every external resource URL Gradle reads via the internal BuildOperationListener API and emits a
- * Flathub-compliant flatpak-sources.json at build finish.
- *
- * URL is authoritative (taken straight from the build op); the on-disk file is found via Gradle's files-2.1 layout,
- * with a Module-Metadata-aware fallback for jars whose cache name differs from their URL name; SHA-256 is computed from
- * that exact file.
- *
- * Internal APIs touched (acceptable trade-off; same path flatpak-gradle-generator uses):
- * - org.gradle.internal.operations.BuildOperationListener / BuildOperationListenerManager
- * - org.gradle.internal.resource.ExternalResourceReadBuildOperationType
- * - org.gradle.api.internal.project.ProjectInternal (for .services)
- */
-class FlatpakOpsPlugin : Plugin<Project> {
-
- override fun apply(target: Project) {
- check(target == target.rootProject) { "meshtastic.flatpak-ops must be applied to the root project" }
-
- // Prefer the URL set populated by gradle/init-scripts/flatpak-ops.init.gradle.kts.
- // The init script attaches its listener BEFORE any plugin/project resolution, so it
- // captures bootstrap downloads (kotlin-dsl plugin marker, build-logic deps) that a
- // listener registered here would miss. If the init script wasn't passed via -I, we
- // fall back to a locally-attached listener β€” incomplete for build-logic deps but
- // useful for developer debugging. No buildFinished cleanup here: this plugin loads in
- // every normal build, and gradle.buildFinished is incompatible with the configuration
- // cache. The fallback is rarely used and the per-build leak is benign.
- @Suppress("UNCHECKED_CAST")
- val capturedUrls: MutableSet<String> =
- (target.gradle.extensions.findByName("flatpakOpsCapturedUrls") as? MutableSet<String>)
- ?: ConcurrentHashMap.newKeySet<String>().also { fallback ->
- val manager = (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java)
- manager.addListener(OpListener(fallback))
- target.logger.warn(
- "flatpak-ops: init script not loaded; build-logic bootstrap URLs will be missing. " +
- "Pass -I gradle/init-scripts/flatpak-ops.init.gradle.kts for a complete manifest.",
- )
- }
-
- val outputProvider = target.layout.buildDirectory.file("flatpak-ops-sources.json")
-
- target.tasks.register("captureFlatpakSources") {
- group = "flatpak"
- description = "Emit flatpak-sources.json from URLs captured via BuildOperationListener."
- outputs.upToDateWhen { false }
- // Order after the resolution-emitting tasks so we don't snapshot capturedUrls before
- // their downloads happen. mustRunAfter is conditional β€” only the scheduled task enforces.
- mustRunAfter(":desktopApp:assemble", ":desktopApp:packageUberJarForCurrentOS")
- val proj = target
- val urlsRef = capturedUrls
- val outFile = outputProvider
- doLast { writeSources(proj, urlsRef.toList(), outFile.get().asFile) }
- }
- }
-
- private class OpListener(private val urls: MutableSet<String>) : BuildOperationListener {
- override fun started(op: BuildOperationDescriptor, e: OperationStartEvent) = Unit
-
- override fun progress(id: OperationIdentifier, e: OperationProgressEvent) = Unit
-
- override fun finished(op: BuildOperationDescriptor, e: OperationFinishEvent) {
- val details = op.details as? ExternalResourceReadBuildOperationType.Details ?: return
- if (e.failure != null) return
- // No host/scheme filtering here: non-Maven URLs (distribution zips, repo listings, etc.)
- // naturally drop out in writeSources() when locateCacheFile() can't find them under
- // files-2.1. Keeping this listener permissive avoids hardcoding repo allowlists.
- urls.add(details.location)
- }
- }
-
- private fun writeSources(project: Project, urls: List<String>, output: File) {
- val filesRoot = File(project.gradle.gradleUserHomeDir, "caches/modules-2/files-2.1")
- val entries: List<Map<String, Any>> =
- urls
- .distinct()
- .filterNot { url ->
- // Exclude sources/javadoc jars: they're not needed for an offline build and
- // inflate the manifest. Match on URL filename, not cache-relative path.
- val tail = url.substringAfterLast('/')
- tail.endsWith("-sources.jar") || tail.endsWith("-javadoc.jar")
- }
- .sorted()
- .mapNotNull { url ->
- val cacheFile = locateCacheFile(filesRoot, url)
- if (cacheFile == null) {
- project.logger.info("flatpak-ops: no cache file for {} (skipped)", url)
- return@mapNotNull null
- }
- val rel = cacheFile.relativeTo(filesRoot).path.replace('\\', '/').split('/')
- if (rel.size < CACHE_PATH_SEGMENTS) return@mapNotNull null
- val group = rel[0]
- val artifact = rel[1]
- val version = rel[2]
- val groupPath = group.replace('.', '/')
- // dest-filename tracks the URL, not the cache: Gradle Module Metadata can declare
- // files[].name β‰  files[].url, and the offline repo must serve at the URL path.
- val urlFilename = URI(url).path.trimEnd('/').substringAfterLast('/')
- val entry =
- mutableMapOf<String, Any>(
- "type" to "file",
- "url" to url,
- "sha256" to sha256(cacheFile),
- "dest" to "offline-repository/$groupPath/$artifact/$version",
- "dest-filename" to urlFilename,
- )
- mirrorsFor(url).takeIf { it.isNotEmpty() }?.let { entry["mirror-urls"] = it }
- entry
- }
- output.parentFile?.mkdirs()
- output.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(entries)))
- project.logger.lifecycle(
- "flatpak-ops: captured {} URLs, emitted {} sources to {}",
- urls.size,
- entries.size,
- output.absolutePath,
- )
- }
-
- /**
- * Map a Maven URL to its file in Gradle's files-2.1 cache (layout:
- * `<group-with-dots>/<artifact>/<version>/<content-sha1>/<filename>`). Group is derived from the URL path β€”
- * `(artifact, version, filename)` is not unique across groups (e.g. androidx.annotation:annotation:1.10.0 collides
- * with org.jetbrains.compose.annotation-internal:annotation:1.10.0), so we probe every suffix of the leading path
- * segments and the longest match wins (this also strips arbitrary repo prefixes like `/maven2/`, `/m2/`).
- *
- * Two-tier lookup: the fast path assumes the cache filename matches the URL filename (true for pom/module always,
- * and most jars). For jars where Gradle Module Metadata renames the artifact locally (files[].name β‰  files[].url β€”
- * e.g. com.mikepenz:aboutlibraries-compose-core-jvm publishes aboutlibraries-compose-core-jvm-14.2.1.jar but caches
- * it as aboutlibraries-compose-jvm.jar) we parse the sibling .module file to resolve the real cache path.
- */
- private fun locateCacheFile(filesRoot: File, url: String): File? {
- val path = URI(url).path.trimEnd('/').split('/').filter { it.isNotEmpty() }
- val tail = path.takeLast(URL_TRAILING_SEGMENTS)
- if (!filesRoot.isDirectory || tail.size < URL_TRAILING_SEGMENTS) return null
- val (artifact, version, filename) = tail
- val groupCandidates = path.dropLast(URL_TRAILING_SEGMENTS)
- // files-2.1 uses dot-joined group as a SINGLE directory, e.g. `androidx.annotation/`, not
- // `androidx/annotation/` β€” so join with '.' here.
- return groupCandidates.indices.firstNotNullOfOrNull { start ->
- val groupDir = groupCandidates.drop(start).joinToString(".")
- val versionDir =
- File(filesRoot, "$groupDir/$artifact/$version").takeIf(File::isDirectory)
- ?: return@firstNotNullOfOrNull null
- val shaDirs = versionDir.listFiles { f -> f.isDirectory } ?: return@firstNotNullOfOrNull null
- shaDirs.map { shaDir -> File(shaDir, filename) }.firstOrNull { it.isFile }
- ?: filename.takeIf { it.endsWith(".jar") }?.let { resolveJarViaModuleMetadata(versionDir, shaDirs, it) }
- }
- }
-
- @Suppress("UNCHECKED_CAST")
- private fun resolveJarViaModuleMetadata(versionDir: File, shaDirs: Array<File>, urlFilename: String): File? {
- val moduleFile =
- shaDirs.firstNotNullOfOrNull { dir ->
- dir.listFiles()?.firstOrNull { it.isFile && it.name.endsWith(".module") }
- }
- val entry =
- moduleFile
- ?.let { runCatching { JsonSlurper().parse(it) as? Map<String, Any?> }.getOrNull() }
- ?.let { it["variants"] as? List<Map<String, Any?>> }
- ?.flatMap { (it["files"] as? List<Map<String, Any?>>).orEmpty() }
- ?.firstOrNull { it["url"] == urlFilename }
- val name = entry?.get("name") as? String
- val sha1 = entry?.get("sha1") as? String
- return if (name != null && sha1 != null) File(versionDir, "$sha1/$name").takeIf(File::isFile) else null
- }
-
- /**
- * Derive fallback mirror URLs from the primary URL's host. Only Maven Central has well-known public mirrors; for
- * everything else (Google, JitPack, Gradle plugin portal, snapshot repos), we trust the primary URL since these
- * hosts don't have stable mirrors anyway. The URL itself is authoritative β€” we just rewrite the host while
- * preserving the path.
- */
- private fun mirrorsFor(url: String): List<String> {
- val uri = runCatching { URI(url) }.getOrNull()
- val host = uri?.host
- val path = uri?.rawPath
- return if (host != null && path != null && host in MAVEN_CENTRAL_HOSTS) {
- MAVEN_CENTRAL_HOSTS.filter { it != host }.map { "https://$it$path" } +
- "https://maven-central.storage-download.googleapis.com$path"
- } else {
- emptyList()
- }
- }
-
- private fun sha256(file: File): String {
- val md = MessageDigest.getInstance("SHA-256")
- file.inputStream().use { stream ->
- val buf = ByteArray(BUFFER_SIZE)
- while (true) {
- val n = stream.read(buf)
- if (n <= 0) break
- md.update(buf, 0, n)
- }
- }
- return hex(md.digest())
- }
-
- private fun hex(bytes: ByteArray): String {
- val digits = "0123456789abcdef"
- val chars = CharArray(bytes.size * HEX_CHARS_PER_BYTE)
- for (i in bytes.indices) {
- val v = bytes[i].toInt() and BYTE_MASK
- chars[i * HEX_CHARS_PER_BYTE] = digits[v ushr NIBBLE_BITS]
- chars[i * HEX_CHARS_PER_BYTE + 1] = digits[v and NIBBLE_MASK]
- }
- return String(chars)
- }
-
- private companion object {
- private val MAVEN_CENTRAL_HOSTS = listOf("repo.maven.apache.org", "repo1.maven.org")
- private const val BUFFER_SIZE = 8192
- private const val CACHE_PATH_SEGMENTS = 5
- private const val URL_TRAILING_SEGMENTS = 3
- private const val HEX_CHARS_PER_BYTE = 2
- private const val BYTE_MASK = 0xFF
- private const val NIBBLE_BITS = 4
- private const val NIBBLE_MASK = 0x0F
- }
-}

diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index 82089c17dd..8e074ab065 100644
--- a/build-logic/settings.gradle.kts
+++ b/build-logic/settings.gradle.kts
@@ -30,11 +30,6 @@ pluginManagement {
}
}
-// Version mirrored in libs.versions.toml for Renovate tracking.
-plugins {
- id("com.gradle.develocity") version "4.4.2"
-}
-
dependencyResolutionManagement {
repositories {
mavenCentral()
@@ -55,10 +50,8 @@ dependencyResolutionManagement {
}
}
-// Shared Develocity and Build Cache configuration
-apply(from = "../gradle/develocity.settings.gradle")
+// Build Cache configuration (HTTP remote cache + local)
+apply(from = "../gradle/build-cache.settings.gradle")
rootProject.name = "build-logic"
include(":convention")
-include(":flatpak-ops")
-

diff --git a/build.gradle.kts b/build.gradle.kts
index f101ff4b8a..0cff19056c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -40,7 +40,19 @@ plugins {
alias(libs.plugins.test.retry) apply false
alias(libs.plugins.meshtastic.root)
id("meshtastic.docs")
- id("meshtastic.flatpak-ops")
+}
+
+plugins.withId("org.meshtastic.flatpak.sources") {
+ extensions.configure<org.meshtastic.flatpak.sources.FlatpakSourcesExtension> {
+ outputFile.set(layout.buildDirectory.file("flatpak-sources.json"))
+ mustRunAfterTasks.set(listOf(":desktopApp:assemble", ":desktopApp:packageUberJarForCurrentOS"))
+ // Force-resolve platform-specific native artifacts not resolved on the generation host
+ targetPlatforms.set(setOf("linux-x64", "linux-arm64"))
+ platformDependencies.set(setOf(
+ "org.jetbrains.skiko:skiko-awt-runtime-{platform}:0.144.6",
+ "org.jetbrains.compose.desktop:desktop-jvm-{platform}:1.11.0",
+ ))
+ }
}
dependencies {

diff --git a/gradle/build-cache.settings.gradle b/gradle/build-cache.settings.gradle
new file mode 100644
index 0000000000..b2ce0c12e1
--- /dev/null
+++ b/gradle/build-cache.settings.gradle
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+def getMeshProperty(String key) {
+ def env = System.getenv(key)
+ if (env) return env
+
+ def currentDir = settingsDir
+ while (currentDir != null) {
+ def localFile = new File(currentDir, "local.properties")
+ if (localFile.exists()) {
+ def props = new Properties()
+ localFile.withInputStream { props.load(it) }
+ if (props.containsKey(key)) return props.getProperty(key)
+ }
+ def configFile = new File(currentDir, "config.properties")
+ if (configFile.exists()) {
+ def props = new Properties()
+ configFile.withInputStream { props.load(it) }
+ if (props.containsKey(key)) return props.getProperty(key)
+ }
+ currentDir = currentDir.parentFile
+ }
+ return null
+}
+
+buildCache {
+ local {
+ enabled = true
+ }
+ remote(HttpBuildCache) {
+ // Some servers have issues with Expect: 100-continue and return 403.
+ useExpectContinue = false
+ def cacheUrl = getMeshProperty("GRADLE_CACHE_URL")?.trim()
+ def cacheUsername = getMeshProperty("GRADLE_CACHE_USERNAME")?.trim()
+ def cachePassword = getMeshProperty("GRADLE_CACHE_PASSWORD")?.trim()
+
+ if (cacheUrl) {
+ def isLogic = settingsDir.name == "build-logic"
+ logger.lifecycle "Meshtastic ${isLogic ? 'Build Logic' : 'Build'}: Remote cache URL found."
+
+ // Ensure trailing slash for cache servers
+ url = cacheUrl.endsWith("/") ? cacheUrl : "${cacheUrl}/"
+
+ if (cacheUsername && cachePassword) {
+ credentials {
+ username = cacheUsername
+ password = cachePassword
+ }
+ }
+
+ allowInsecureProtocol = true
+ allowUntrustedServer = true
+
+ // Keep push enabled if credentials are provided.
+ // This naturally disables push for fork PRs which don't have access to secrets.
+ push = (cacheUsername && cachePassword)
+
+ enabled = true
+ } else {
+ enabled = false
+ }
+ }
+}

diff --git a/gradle/develocity.settings.gradle b/gradle/develocity.settings.gradle
deleted file mode 100644
index 24fdc2e356..0000000000
--- a/gradle/develocity.settings.gradle
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-def getMeshProperty(String key) {
- def env = System.getenv(key)
- if (env) return env
-
- def currentDir = settingsDir
- while (currentDir != null) {
- def localFile = new File(currentDir, "local.properties")
- if (localFile.exists()) {
- def props = new Properties()
- localFile.withInputStream { props.load(it) }
- if (props.containsKey(key)) return props.getProperty(key)
- }
- def configFile = new File(currentDir, "config.properties")
- if (configFile.exists()) {
- def props = new Properties()
- configFile.withInputStream { props.load(it) }
- if (props.containsKey(key)) return props.getProperty(key)
- }
- currentDir = currentDir.parentFile
- }
- return null
-}
-
-develocity {
- buildScan {
- capture {
- fileFingerprints = true
- }
- // Publish scans in CI for build failure debugging and performance profiling.
- // Uses scans.gradle.com free tier (public scans). Disabled locally.
- def isCi = System.getenv("CI") != null
- publishing.onlyIf { isCi }
- uploadInBackground = !isCi
- termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use"
- termsOfUseAgree = "yes"
- }
- buildCache {
- local {
- enabled = true
- }
- remote(HttpBuildCache) {
- // Some servers have issues with Expect: 100-continue and return 403.
- useExpectContinue = false
- def cacheUrl = getMeshProperty("GRADLE_CACHE_URL")?.trim()
- def cacheUsername = getMeshProperty("GRADLE_CACHE_USERNAME")?.trim()
- def cachePassword = getMeshProperty("GRADLE_CACHE_PASSWORD")?.trim()
-
- if (cacheUrl) {
- def isLogic = settingsDir.name == "build-logic"
- logger.lifecycle "Meshtastic ${isLogic ? 'Build Logic' : 'Build'}: Remote cache URL found."
-
- // Ensure trailing slash for Develocity/GE servers
- url = cacheUrl.endsWith("/") ? cacheUrl : "${cacheUrl}/"
-
- if (cacheUsername && cachePassword) {
- credentials {
- username = cacheUsername
- password = cachePassword
- }
- }
-
- allowInsecureProtocol = true
- allowUntrustedServer = true
-
- // Keep push enabled if credentials are provided.
- // This naturally disables push for fork PRs which don't have access to secrets.
- push = (cacheUsername && cachePassword)
-
- enabled = true
- } else {
- enabled = false
- }
- }
- }
-}

diff --git a/gradle/init-scripts/flatpak-ops.init.gradle.kts b/gradle/init-scripts/flatpak-ops.init.gradle.kts
deleted file mode 100644
index 738713c0f9..0000000000
--- a/gradle/init-scripts/flatpak-ops.init.gradle.kts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * Init script for meshtastic.flatpak-ops. Attaches a BuildOperationListener
- * BEFORE any project or plugin resolution happens β€” which is necessary because
- * the flatpak-ops plugin itself lives in build-logic, and any artifacts pulled
- * to bootstrap build-logic (kotlin-dsl plugin marker, detekt, etc.) would be
- * invisible to a listener registered later from a root-project plugin.
- *
- * Captured URLs are stored on `gradle.extensions` under the key below; the
- * captureFlatpakSources task (registered by FlatpakOpsPlugin) reads them.
- *
- * Pass to Gradle via:
- * ./gradlew -I gradle/init-scripts/flatpak-ops.init.gradle.kts ...
- */
-
-import org.gradle.api.internal.GradleInternal
-import org.gradle.internal.operations.BuildOperationDescriptor
-import org.gradle.internal.operations.BuildOperationListener
-import org.gradle.internal.operations.BuildOperationListenerManager
-import org.gradle.internal.operations.OperationFinishEvent
-import org.gradle.internal.operations.OperationIdentifier
-import org.gradle.internal.operations.OperationProgressEvent
-import org.gradle.internal.operations.OperationStartEvent
-import org.gradle.internal.resource.ExternalResourceReadBuildOperationType
-import java.util.concurrent.ConcurrentHashMap
-
-val capturedUrls: MutableSet<String> = ConcurrentHashMap.newKeySet()
-gradle.extensions.add("flatpakOpsCapturedUrls", capturedUrls)
-
-val manager =
- (gradle as GradleInternal).services.get(BuildOperationListenerManager::class.java)
-
-val listener =
- object : BuildOperationListener {
- override fun started(op: BuildOperationDescriptor, e: OperationStartEvent) = Unit
- override fun progress(id: OperationIdentifier, e: OperationProgressEvent) = Unit
- override fun finished(op: BuildOperationDescriptor, e: OperationFinishEvent) {
- val details = op.details as? ExternalResourceReadBuildOperationType.Details ?: return
- if (e.failure != null) return
- capturedUrls.add(details.location)
- }
- }
-manager.addListener(listener)
-
-// Detach at build finish so a long-lived daemon doesn't accumulate one listener per build.
-// buildFinished is deprecated and breaks the configuration cache β€” fine here because this
-// script is only loaded via `-I` in the verify flow, which uses --no-configuration-cache.
-// Revisit (Flow API or AutoCloseable BuildService) when we drop Gradle 9 support.
-@Suppress("DEPRECATION")
-gradle.buildFinished { manager.removeListener(listener) }

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index fe12c5d0fa..da8e522111 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -90,9 +90,8 @@ jmdns = "3.6.3"
qrcode-kotlin = "4.5.0"
takpacket-sdk = "0.3.0"
-# Gradle Enterprise & Toolchains Plugins
+# Gradle Plugins
develocity = "4.4.2"
-custom-user-data = "2.6.0"
foojay-resolver = "1.0.0"
@@ -325,7 +324,6 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" }
develocity = { id = "com.gradle.develocity", version.ref = "develocity" }
-custom-user-data = { id = "com.gradle.common-custom-user-data-gradle-plugin", version.ref = "custom-user-data" }
foojay-resolver = { id = "org.gradle.toolchains.foojay-resolver", version.ref = "foojay-resolver" }
# Meshtastic

diff --git a/scripts/verify-flatpak/verify.sh b/scripts/verify-flatpak/verify.sh
index c8a6f49346..89ef6a3865 100755
--- a/scripts/verify-flatpak/verify.sh
+++ b/scripts/verify-flatpak/verify.sh
@@ -78,11 +78,10 @@ fi
if [[ $SKIP_REGEN -eq 0 ]]; then
step "Regenerating flatpak-sources.json via isolated Gradle home"
rm -rf "$GRADLE_HOME_ISOLATED"
- # Drive the same task the in-flatpak build runs so runtime-classpath deps (skiko, ktor-cio,
- # datastore-proto, etc.) are resolved and captured β€” :assemble only triggers compileClasspath.
+ # The settings plugin (org.meshtastic.flatpak.sources.settings) captures URLs from
+ # build start β€” no init script or -I flag needed.
(cd "$REPO_ROOT" && ./gradlew --no-build-cache --no-configuration-cache \
-Dgradle.user.home="$GRADLE_HOME_ISOLATED" \
- -I gradle/init-scripts/flatpak-ops.init.gradle.kts \
:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources)
cp "$REPO_ROOT/build/flatpak-ops-sources.json" "$SOURCES_JSON"
elif [[ ! -f "$SOURCES_JSON" ]]; then

diff --git a/settings.gradle.kts b/settings.gradle.kts
index a84588b6da..28ce3c90e7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -32,9 +32,9 @@ pluginManagement {
}
plugins {
- id("org.gradle.toolchains.foojay-resolver") version "1.0.0"
id("com.gradle.develocity") version "4.4.2"
- id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0"
+ id("org.gradle.toolchains.foojay-resolver") version "1.0.0"
+ id("org.meshtastic.flatpak.sources.settings") version "0.1.1"
}
@Suppress("UnstableApiUsage")
@@ -70,8 +70,22 @@ rootProject.name = "MeshtasticAndroid"
// https://docs.gradle.org/current/userguide/declaring_dependencies.html#sec:type-safe-project-accessors
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
-// Shared Develocity and Build Cache configuration
-apply(from = "gradle/develocity.settings.gradle")
+// Build Cache configuration (HTTP remote cache + local)
+apply(from = "gradle/build-cache.settings.gradle")
+
+// Build Scans β€” publish in CI only for debugging and performance profiling.
+develocity {
+ buildScan {
+ capture {
+ fileFingerprints = true
+ }
+ val isCi = providers.environmentVariable("CI").isPresent
+ publishing.onlyIf { isCi }
+ uploadInBackground = !isCi
+ termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use"
+ termsOfUseAgree = "yes"
+ }
+}
@Suppress("UnstableApiUsage")
toolchainManagement {

Served by rngit 1.5.4 - Generated in 0.11s